build: create a new release version 1.16.0#1416
Draft
SERDUN wants to merge 478 commits into
Draft
Conversation
…ressed at root (#1090) * fix(android): move app to background on back press instead of destroying Activity When pressing Back on the root screen, Android was calling finish() on MainActivity, destroying the Flutter engine and tearing down any active WebRTC call (DTLS alert -> ice_hangup -> BYE). Override onBackPressed to call moveTaskToBack(true) so the app minimizes without destroying the engine. * fix(android): minimize app instead of destroying Activity when Flutter stack is empty Override popSystemNavigator() which is called only after Flutter exhausts its own navigation stack. Replaces the default finish() with moveTaskToBack(true) so the Flutter engine stays alive with any active WebRTC call. Previous attempt overrode onBackPressed() which bypassed Flutter router entirely. * fix(android): handle moveTaskToBack failure and document intentional minimize-always behavior
#1091) * fix(signaling): make hub protocol bidirectional — remove isolate auto-reconnect The background foreground-service isolate was managing its own reconnect timer independently of SignalingReconnectController in the main isolate. This caused a race: the background could reconnect while the app was locked, producing a SignalingConnected event that set _wasConnected=true, so the next disconnect triggered an error toast visible on unlock (green + error). Root fix: extend the hub protocol with connect/disconnect commands so the main isolate (SignalingReconnectController via SignalingHubModule) is the single decision-maker for all reconnects. Changes: - signaling_hub_command: add SignalingHubConnectCommand / SignalingHubDisconnectCommand - signaling_hub_client: add sendConnect() / sendDisconnect() fire-and-forget methods - signaling_hub: handle connect/disconnect commands, forward to SignalingModule - signaling_hub_module: connect()/disconnect() forward commands to hub client instead of being no-ops - signaling_foreground_isolate_manager: remove _reconnectTimer and _scheduleReconnect(); isolate no longer auto-reconnects on disconnect or connection failure — it only reconnects when main isolate calls handleStatus(enabled:true) and the module is not connected - tests: update hub_module_test and foreground_isolate_manager_test to cover new behavior; all 158 tests pass * fix(signaling): handle persistent-service mode reconnect when app is closed In persistent signaling mode the foreground service outlives the app. When the app is closed there are no hub subscribers, so SignalingReconnectController is not running and cannot drive reconnects. Fix: restore the reconnect timer in SignalingForegroundIsolateManager but gate it on SignalingHub.hasSubscribers. When subscribers are present (app open), reconnect is delegated to SignalingReconnectController as before. When no subscribers are present (app closed, persistent mode), the background isolate schedules a local reconnect using the delay hint from the disconnect/failure event. Null delay (e.g. code 1002) is treated as "do not reconnect" in both modes. When handleStatus(enabled: true) is called while a timer is pending, the timer is cancelled so the incoming caller takes responsibility. - SignalingHub: add hasSubscribers getter - SignalingForegroundIsolateManager: restore _reconnectTimer and _scheduleReconnect(); schedule only when !hub.hasSubscribers - Tests: 4 new persistent-mode tests covering auto-reconnect on disconnect, auto-reconnect on connection failed, no reconnect on null delay, and timer cancellation on stop(); all 68 isolate+hub tests pass * fix(signaling): address Copilot review comments - signaling_hub: guard connect/disconnect commands with subscriber check — reject commands from unknown consumers (consistent with execute command) - signaling_hub: fix hasSubscribers doc — says "any subscriber" not "main-isolate subscriber" (push-notification isolate can also subscribe) - signaling_hub_module: update class doc — connect/disconnect now forward commands to hub client, not no-ops
…gnalingReconnectController (#1089) * refactor(signaling): centralize disconnect notification decisions in SignalingReconnectController Move all notification decisions out of CallBloc.__onSignalingClientEventDisconnected into the single onConnectionFailed callback of SignalingReconnectController. The callback now receives SignalingDisconnectCode? knownCode so the consumer can decide what to show: - signalingKeepaliveTimeoutError / controllerForceAttachClose → silent (no toast) - sessionMissedError → SignalingSessionMissedNotification - null (connect failure) → SignalingConnectFailedNotification - other codes → SignalingDisconnectNotification(knownCode) __onSignalingClientEventDisconnected now only updates CallState. This aligns the code with the comment that was already there: "notification decisions are fully handled by _reconnectController". Fixes keepalive timeout (4502) appearing as a user-visible error on lock-screen unlock — it is now silently swallowed and the reconnect proceeds transparently. * refactor(signaling): log silent reconnect codes in onConnectionFailed * refactor(signaling): add comment for silent reconnect codes in onConnectionFailed * refactor(signaling): address Copilot review comments on PR#1089 - SignalingFailureInfo record replaces bare knownCode in onConnectionFailed, forwarding systemCode/systemReason so SignalingDisconnectNotification retains full diagnostic details - signalingKeepaliveTimeoutError sets lastSignalingDisconnectCode=null to prevent connectIssue UI state (same as controllerForceAttachClose) - onConnectionFailed uses switch expression for clarity - fix doc comment example and __onSignalingClientEventDisconnected comment wording * fix(signaling): reset _wasConnected on app pause to prevent spurious toast on unlock (WT-1221) When notifyAppPaused disconnects intentionally, _wasConnected is now reset to false. Previously it stayed true after a successful session, so the first post-unlock SignalingConnectionFailed hit the '_wasConnected' fast-path and fired onConnectionFailed immediately — bypassing the consecutive-failure threshold and showing 'Connecting to the core failed' on screen unlock. With this fix the post-unlock reconnect is treated as a fresh attempt: the threshold applies and transient DNS/network failures are suppressed. * fix(signaling): suppress background notifications and reset state on resume On Android, SignalingHubModule.connect/disconnect are no-ops — the foreground-service isolate owns the WebSocket lifecycle and reconnects independently. When the app is backgrounded, background reconnects set _wasConnected = true. A subsequent failure then fires onConnectionFailed, which queues a toast that appears incorrectly when the app resumes (green status + error toast simultaneously). Two fixes: 1. Guard _onConnectionFailed calls with (_appActive || _hasActiveCalls). No notifications are queued while the user cannot see them. 2. Reset _wasConnected and _consecutiveFailures in notifyAppResumed so background reconnect state does not bypass the failure threshold after the app comes to foreground. Adds two new regression tests covering both scenarios. * docs(signaling): update stale comments in SignalingReconnectController After PR #1091 SignalingHubModule.connect()/disconnect() are no longer no-ops — the hub protocol is now bidirectional. Update two comments that still referenced the old "hub handles reconnects independently" behaviour: - notifyAppResumed(): explain that the _wasConnected reset is needed for persistent-service mode session buffer replay, not for independent background reconnects - _onEvent: replace the outdated no-ops note with the actual reason for notification suppression (persistent-mode reconnects while app is closed) * fix(signaling): preserve _wasConnected during active-call app resume notifyAppResumed() was unconditionally resetting _wasConnected = false. When the app resumes during an active call (edge case: brief background), this caused a subsequent SignalingConnectionFailed to be treated as an initial connect failure (going through the consecutive-failure threshold) instead of an established-session drop that notifies immediately. Fix: only reset _wasConnected when !_hasActiveCalls. The consecutive- failure counter is still reset unconditionally for a clean retry sequence. * test(signaling): cover _wasConnected preservation during active-call resume Add test verifying that notifyAppResumed() does not reset _wasConnected when _hasActiveCalls is true. Without the fix a SignalingConnectionFailed after resume during a call would go through the consecutive-failure threshold instead of notifying immediately (established-session drop).
…t GMS (#1092) * fix: seed initial push tokens and call state in SessionStatusCubit On devices without Google Mobile Services (e.g. Huawei), PushTokensBloc never emits after initialization because GMS availability check returns a terminal status and aborts token retrieval without emitting any state. As a result, _lastPushTokensState remained null indefinitely, causing the condition `pushTokens != null && call != null` in _emitCombinedStatus to never pass — so the UI was stuck at SessionStatus.inProgress regardless of the actual signaling/registration state. Fix: seed _lastPushTokensState and _lastCallState from the blocs' initial states in the constructor, so subsequent _onCallChanged callbacks can emit the correct status even when PushTokensBloc never emits. * refactor: remove unused optional params from _emitCombinedStatus
* fix(signaling): cancel in-flight connect on disconnect via generation counter disconnect() returned early when _client==null (connect still awaiting factory), leaving _connecting=true and the in-flight _connectAsync running. The next connect() was silently dropped, and if the stale factory eventually resolved, the module set _client and emitted SignalingConnected even though disconnect() had been called. Root cause: the guard in disconnect() (`if (client == null) return`) prevented it from resetting _connecting or invalidating the async task. Fix: introduce a monotonically increasing _generation counter. - connect() captures the current generation before starting _connectAsync - disconnect() increments _generation and resets _connecting=false - _connectAsync checks its generation at every suspension point; if it no longer matches, it cleans up its client without emitting events - the finally block only resets _connecting for the current generation Adds four tests reproducing the race: two document the broken behavior (expected to pass after the fix), two verify correct behavior. * fix(signaling): add diagnostic logs for cancelled in-flight connect * refactor(signaling): replace generation counter with Object identity token * fix(signaling): address Copilot review comments * fix(signaling): restore library directive to silence dangling doc comment warning
#1095) * fix(session): investigate stuck connectivityNone status after network restore * fix(session): clear stale error state when signaling starts reconnecting When SignalingConnecting fires after a failed connection attempt, lastSignalingClientConnectError and lastSignalingDisconnectCode were not cleared. This left the status stuck on connectError/connectIssue ('Connection error' / 'Connection issue') even though a fresh connect attempt was already in progress. After this fix, SignalingConnecting resets all stale error fields so the status correctly shows inProgress ('Connection in progress') for the duration of the reconnect.
* fix(l10n): escape apostrophe in Italian ARB plural string ICU message format treats single quotes as escape characters. Unescaped apostrophe in `dell'ultimo` caused ICU lexing error and broke gen_localizations during the Android build. Fix: replace `'` with `''` in the affected plural form. * fix(l10n): replace ASCII apostrophe with U+2019 in Italian ARB plural ICU message format treats ASCII ' as an escape character, causing a lexing error in gen_localizations during CI Android build. Fix: replace the ASCII apostrophe in `dell'ultimo` with U+2019 RIGHT SINGLE QUOTATION MARK, which is not an ICU syntax character. Visual output is unchanged. Regenerated app_localizations_it.g.dart accordingly.
* docs(signaling): document why remoteMessaging is the correct FGS type SignalingForegroundService maintains a persistent WebSocket to the signaling server and never accesses microphone, camera, or location. remoteMessaging is the semantically accurate type for this use case. Added inline comments in AndroidManifest.xml and SignalingForegroundService.kt explaining why remoteMessaging is chosen over phoneCall, microphone, or dataSync, to prevent future misunderstandings during reviews or platform upgrades. * docs(signaling): tighten remoteMessaging comments — positive rationale only * docs(signaling): clarify remoteMessaging type is passed not declared conditionally
…n Xiaomi (#1099) * chore(signaling): umbrella branch for Xiaomi FGS crash fixes * fix(signaling): call startForeground() first in onCreate() to reduce FGS timeout risk (#1100) Move startForeground() to be the first call after super.onCreate(), before any SharedPreferences IPC. Under memory pressure (Xiaomi HyperOS), the SharedPrefs read in getCallbackDispatcher() can block the main thread for 50-500ms, pushing startForeground() past Xiaomi's aggressive ~1.2s window and causing ForegroundServiceDidNotStartInTimeException. startForeground() has no dependency on FlutterEngineHelper or callbackHandle — the engine starts in onStartCommand(), which runs after onCreate() completes. * fix(signaling): parallelize Pigeon IPC calls in _startService() to reduce FGS delay (#1101) * fix(signaling): parallelize Pigeon IPC calls in _startService() to reduce FGS delay Replace three sequential await calls with Future.wait() so startService() (which calls startForegroundService()) is dispatched without waiting for three Binder round-trips first. All four calls share the same BinaryMessenger, which delivers messages to the Kotlin main-thread Looper in FIFO order. startService() is always processed after the credential writes, so synchronizeIsolate() reads correct data on the first attempt. Under memory pressure (Xiaomi HyperOS), each sequential Binder round-trip adds ~100–300 ms. Removing three of them saves ~300–900 ms before startForegroundService() is called, widening the margin within the vendor-specific FGS promotion window. * fix(signaling): parallelize credential writes, keep startService() sequential Address review concerns: - Separate credential writes (Future.wait) from startService() (sequential await) so startService() is only called after all SharedPreferences writes complete - Removes the cross-channel FIFO ordering assumption: credentials are independent of each other and run concurrently; startService() is explicitly sequenced after Future.wait() resolves - A failure in any credential write now prevents startService() from being dispatched, preserving the original error-isolation behavior Still removes two sequential Binder round-trips (~200–600 ms under memory pressure) before startForegroundService() is called. * fix(signaling): persistent mode FGS recovery after OS kill (#1103) * fix(signaling): persistent mode FGS recovery after OS kill Part 1 — FCM path: add WebtritSignalingService.restoreService() static method (Pigeon connect() → Kotlin) called from onPushNotificationSyncCallback finally block after _disposeContext(). Restores the persistent WebSocket for future calls when FGS is dead and FCM push is the first trigger. Part 2 — No-GMS path: add SignalingRestartWorker (WorkManager one-shot) enqueued from onDestroy (15 s) and onTaskRemoved (1 s) in persistent mode. Result.retry() handles Android 12+ ForegroundServiceStartNotAllowedException. SignalingRestartWorker.remove() called first in stopService() to prevent restart after explicit logout. Also fixes thread-safety bug: isRunning is now @volatile. Closes bug-3-no-persistent-mode-recovery. * fix(signaling): address Copilot review comments on FGS recovery - cancelUniqueWork instead of cancelAllWorkByTag in SignalingRestartWorker.remove() - doWork(): retry only on ForegroundServiceStartNotAllowedException, failure() on all others - doWork(): gate on tenantId/token in addition to coreUrl - doWork(): Log.e with throwable for full stack trace - connect() in Plugin: add tenantId/token guards; catch ForegroundServiceStartNotAllowedException natively and enqueue WorkManager instead of propagating to Dart - onDestroy/onTaskRemoved: gate WorkManager enqueue on coreUrl+dispatcher to skip after logout - background_isolate_callbacks: fix comment (exception handled natively), pass (e, st) to logger - Add unit tests: plugin_test restoreService channel wiring, signaling_service_test delegation * fix(signaling): address second round Copilot review comments - SignalingRestartWorker: update KDoc to reflect actual retry logic (retry only on ForegroundServiceStartNotAllowedException, failure() on others) - SignalingRestartWorker: Log.w for transient ForegroundServiceStartNotAllowedException, Log.e only for permanent failures — reduces noise in normal retry scenarios - SignalingForegroundService.onDestroy: add tenantId/token guards to align with connect() and doWork() credential checks - SignalingForegroundService.onTaskRemoved: same tenantId/token guard alignment * fix(signaling): remove direct API-31 class reference for minSdk-26 safety Replace `is ForegroundServiceStartNotAllowedException` checks with javaClass.name string comparison in WebtritSignalingServicePlugin and SignalingRestartWorker. Removes direct import of the API-31+ framework class, eliminating any risk of class-verification errors on pre-31 ART. Also removes the now-redundant Build.VERSION.SDK_INT guard and imports. Remove stale inline comment from background_isolate_callbacks.dart. * fix(signaling): restore idiomatic API-31 exception check via @RequiresApi helper javaClass.name string comparison is fragile, not idiomatic, and misses subclasses. Replace with a @RequiresApi(S) helper that holds the `is` check in API-guarded code, satisfying Lint while keeping type safety. The outer Build.VERSION.SDK_INT >= S guard means the helper is never called on pre-31 devices, so no class-loading risk exists. * fix: push isolate reuses FGS hub WebSocket (1-socket invariant) (#1104) * fix: push isolate reuses FGS hub WebSocket instead of opening its own PushNotificationIsolateManager previously always created a SignalingModuleImpl directly, opening a second WebSocket when the FGS hub was already running. This violated the 1-WebSocket invariant. The fix adds createPushIsolateModule() to SignalingServicePlatform and WebtritSignalingService. On Android, the method checks IsolateNameServer for a live FGS hub: if found and acknowledged, a SignalingHubModule is returned and no new connection is opened. When no hub is active (app killed), falls back to a direct SignalingModuleImpl. PushNotificationIsolateManager now exposes an init() method for async hub discovery, called from _getOrInit() before run(). run() skips connect() when the module reports isConnected (hub reuse path). * logs: add diagnostic logs for push isolate signaling module resolution Adds log lines to verify the 1-socket invariant at runtime: - _initSignaling(): logs module type and isConnected after createPushIsolateModule resolves, making hub-reuse vs direct-socket path visible in logcat - close(): logs module type and pending request count on teardown - _getOrInit(): logs before and after init() so init timing is traceable These logs confirm whether the hub path or fallback path is taken on each push notification, and that teardown completes cleanly. * fix: push isolate starts FGS and waits for hub instead of direct socket Previously createPushIsolateModule fell back immediately to a direct SignalingModuleImpl when no hub was running (pushBound mode, first push after app close). This violated the 1-WebSocket invariant: both the push isolate and the Activity would open their own WebSocket connections. New flow: 1. Hub already running -> reuse (persistent mode or second push) 2. No hub -> startFgsOnly(pushBound) -> poll IsolateNameServer until hub registers and acknowledges -> return SignalingHubModule 3. Hub not available after 10 s -> emergency fallback to direct module The push isolate now always shares the same FGS WebSocket with the Activity, matching the intended architecture. * fix: address Copilot review comments on push isolate hub reuse - _tryConnectHub: yield to event loop after ack so hub replay events (SignalingConnected etc.) are processed before returning the module. Prevents run() from calling connect() on an already-live hub session due to isConnected being transiently false while replay is in flight. - run(): throw StateError when called before init() instead of silently leaving _completer unresolved and hanging the caller until timeout. - lefthook.yml: pass {1} to commit-msg-check.sh so the hook reads the actual commit message file (git hook argv) instead of git log -1. * refactor: unify push isolate signaling with Activity via WebtritSignalingService Push isolate now uses WebtritSignalingService(mode: pushBound) directly, the same mechanism the Activity uses. HubConnectionManager inside the service handles FGS start, hub discovery, and auto-reconnect if the hub is killed between push arrival and Activity open. Removes createPushIsolateModule from platform API — it was a one-shot workaround that bypassed HubConnectionManager and had no reconnect logic. Removes _tryConnectHub, _startFgsOnly, and related constants from the Android plugin. The single code path eliminates the duplication. Lifecycle boundary remains unchanged: push mode — FGS lives from push arrival through Activity close persistent — FGS lives indefinitely * fix: address remaining Copilot review comments - Remove unnecessary async from _initSignaling() — nothing is awaited - Rename log field hubConnected -> isConnected (accurate with WebtritSignalingService) - Always call connect() in run() — WebtritSignalingService.connect() is idempotent via _startPending/_isConnected guard; the old conditional was left over from SignalingHubModule direct usage - Fix class doc: qualify 1-WebSocket claim as Android-only - Fix _initSignaling doc: remove auto-reconnect claim, HubConnectionManager handles FGS start and hub discovery, not reconnect after disconnect - commit-msg-check.sh: add guard for missing or unreadable $1 argument * docs: fix stale comments after signaling unification - init() doc: hub discovery and FGS start happen in connect() via HubConnectionManager, not in init() as the old comment stated - background_isolate_callbacks: same correction for _getOrInit comment - commit-msg-check.sh: add set -euo pipefail for consistency with other scripts * refactor: replace nullable signalingModule with late field and remove unnecessary async from init() - _signalingModule/_signalingSubscription: SignalingModule? → late SignalingModule; eliminates null-aware ?. access throughout the class since run() requires init() first - Add _initialized bool flag so close() can guard disposal without accessing late fields prematurely - init(): Future<void> async => _initSignaling() → void init() (sync, no await needed) - run(): null check replaced with !_initialized guard; remove intermediate `module` local - All ?. accesses on _signalingModule replaced with direct . access
…1118) * feat(signaling-service): add simulateKill API for service-restart QA Adds WebtritSignalingService.simulateKill() — stops the Android foreground service immediately without a graceful WebSocket disconnect, preserving SharedPreferences credentials so WorkManager and START_STICKY can restart the service via the same recovery path triggered by a real OS kill. - pigeons/signaling.messages.dart: add simulateKill() to PSignalingServiceHostApi - messages.g.dart / Messages.g.kt: regenerated by Pigeon - SignalingForegroundService.kt: simulateKill() calls stopSelf() directly - WebtritSignalingServicePlugin.kt: delegate to SignalingForegroundService - Android/iOS/platform-interface Dart plugins: implement / no-op override - WebtritSignalingService: expose static simulateKill() * feat(dev-tools): add hidden dev tools screen via 15-tap on about logo Adds a secret diagnostic screen accessible by tapping the app logo 15 times on the About screen. The screen exposes service-level actions for QA and manual testing that should not appear in the normal settings UI. - dev_tools feature: DevToolsScreen + DevToolsScreenPage (@RoutePage) - About screen: 15-tap MultiTapTrigger on the logo navigates to dev-tools; added proper dispose for both triggers - Router: dev-tools route registered under settings - l10n: devTools_* keys added to app_en.arb and regenerated - build_runner: app_router.gr.dart regenerated with DevToolsScreenPageRoute First action: Simulate service kill (calls WebtritSignalingService.simulateKill) * fix(dev-tools): correct import grouping in dev_tools_screen Separate external (auto_route) and internal monorepo packages (webtrit_signaling_service) into distinct import groups per project code standards. Replace relative ../../../ import with package: path. * fix(dev-tools): address Copilot review comments - Extract multi-statement onPressed into _onSimulateKillConfirmed() per single-expression callback rule; wrap simulateKill() with unawaited() - Revert push-bound guard in simulateKill: OS kills the service in all modes, so the API should work regardless of current mode
…lly (#1108) When Android kills the FGS background isolate (e.g. Samsung battery optimisation), the main isolate's ReceivePort goes silent with no onDone or error. HubConnectionManager._module stays non-null (stale object), so begin() returns immediately on the _module != null guard and hub rediscovery never runs. Status is stuck at "Connection in progress" indefinitely. Fix adds two cooperating changes: 1. Liveness ping in SignalingHubClient After subscribe, a periodic timer fires every pingInterval (default 15 s). A SignalingHubPingCommand is sent to the hub; the hub replies with a pong. If no pong arrives within pongTimeout (default 2 s) the client closes its event StreamController, signalling hub death downstream. Both intervals are configurable via HubConnectionManager constructor parameters so they can be tightened in tests. 2. onDone cascade to HubConnectionManager SignalingHubModule now propagates _hubClient.events onDone to its own _controller. HubConnectionManager._moduleSub adds an onDone handler that nulls _module and restarts begin(), which polls IsolateNameServer every 100 ms until the WorkManager-restarted FGS registers a fresh hub port. Recovery path after fix: hub dies → ping timeout (≤17 s) → _controller.close() → SignalingHubModule._onHubDone → module _controller.close() → HubConnectionManager._moduleSub.onDone → _module = null → begin() → _initLoop finds new hub port after WorkManager restarts FGS (~15 s) → SignalingConnected → CallStatus.ready Files changed: signaling_hub_command.dart — add SignalingHubPingCommand signaling_hub_codec.dart — add encodePong / isPong helpers signaling_hub.dart — handle ping → send pong signaling_hub_client.dart — periodic ping timer, pong handling, configurable intervals signaling_hub_module.dart — propagate onDone from client to module controller hub_connection_manager.dart — onDone handler on _moduleSub; pingInterval/pongTimeout params
… is alive (#1119) * fix(signaling-service): recover from hub service death while Activity is alive Three problems exposed by simulateKill in push-bound mode: 1. Stale IsolateNameServer port: when the foreground service dies abruptly (OS kill or simulateKill), the background isolate's SignalingHub port stays registered forever. HubConnectionManager._initLoop found the port on every retry and timed out on ack indefinitely. 2. No service restart in push-bound mode: onDestroy only enqueues SignalingRestartWorker for persistent mode. When the Activity is alive and the service dies, nobody called startService() again. 3. Stuck connected indicator: SignalingConnectionFailed / SignalingDisconnected were never emitted so CallBloc kept the stale connected state. Fix - HubConnectionManager: - Track consecutiveStaleAcks per _initLoop run. - After stalePortThreshold (default 3) consecutive stale ack timeouts: * Remove the dead port from IsolateNameServer so polling resumes cleanly. * Emit SignalingConnectionFailed so the UI and CallBloc see the disconnect. * Call onServiceDead() callback (if set) to restart the service. - Reset counter when port absent (normal polling) or on successful ack. Fix - WebtritSignalingServiceAndroid: - Wire onServiceDead: _onHubServiceDead into HubConnectionManager. - _onHubServiceDead restarts the foreground service via _startService(), works for both push-bound and persistent modes. Recovery path after kill (push-bound, Activity alive): hub ping/pong timeout (~17 s after kill) → 3 consecutive stale acks (~1.5 s) → port cleared, SignalingConnectionFailed emitted, _startService called → new FGS starts, new isolate, new hub port → _initLoop reconnects, stream resumes * fix(signaling-service): suppress NotConnectedException during hub reconnect window During the ~1-2 s between hub death and SignalingConnectionFailed arriving, WebtritSignalingService._isConnected is still true so executeNow() is called. The plugin.execute() throws because _hubManager.isConnected is false. Previously this was a raw StateError which bypassed the retry logic entirely and propagated directly to runZonedGuarded → Firebase Crashlytics, even though the service was already restarting in a controlled way. - plugin.dart: throw NotConnectedException instead of StateError so the error is typed and the request queue can handle it gracefully. - signaling_request_queue._executeWithRetry: catch NotConnectedException and back off 2 s before each retry (up to maxRetryCount times). The isActive() closure evaluates _isConnected live — once SignalingConnectionFailed sets it to false, the retry loop exits silently. Only if the service is still dead after maxRetryCount retries does the exception propagate to Firebase. * fix(signaling-service): address Copilot review comments hub_connection_manager: - Guard the stale-port-threshold block with a generation / isActive / tearingDown check so tearDown() or a concurrent begin() cannot trigger side effects (port removal, SignalingConnectionFailed, onServiceDead) after the loop has already been superseded. - Replace hardcoded Duration(seconds: 3) with kSignalingClientReconnectDelay. plugin: - Wrap onServiceDead callback with unawaited() to make the fire-and-forget intent explicit and keep the void Function() signature satisfied. - Add try/catch in _onHubServiceDead so errors from _startService are logged as SEVERE rather than silently dropped. signaling_request_queue: - _executeWithRetry: rethrow NotConnectedException when !isActive() so that flush() correctly leaves the request in the queue (via its own !isActive() guard) instead of completing it as a false success. - executeNow: wrap _executeWithRetry with a NotConnectedException catch that silently drops the error when !isActive(), preserving the reconnect-window suppression behaviour without affecting flush() semantics.
…nstalls (#1109) AGP 8.x silently injects extractNativeLibs=false as the default, causing bundletool to store .so files uncompressed (Stored/method=0) in the universal APK. On non-certified Android devices (Huawei without GMS, custom ROMs) the package manager does not properly support mmap-from-APK, leaving lib/ empty at runtime — resulting in a MissingLibraryException for libflutter.so on every launch. Setting extractNativeLibs=true overrides the AGP default and causes the installer to always extract native libs to disk during installation, which works on all Android variants regardless of GMS certification. Fixes: WT-1019 Affects: all white-label builds distributed outside Google Play
…T-1039) (#1112) * fix(signaling): silence controllerUnknownError (4400) on reconnect (WT-1039) After multi-day inactivity or multi-device use, the server may emit code 4400 (controllerUnknownError) on the first reconnect attempt due to stale controller state. The subsequent reconnect always succeeds; suppress both the user-facing notification and the transient connectIssue UI state for this code, consistent with how controllerForceAttachClose (4441) is handled. * docs(signaling): add root cause explanation for controllerUnknownError silent reconnect * refactor(call_bloc): split silent-reconnect cases and add reason to log
…(WT-1080) (#1106) * fix(call): [WT-1080] placeholder — implement incoming call hangup when signaling unavailable * fix(call): send decline when incoming call registration fails (WT-1080) Path 1 (_onCallPushEventIncoming): iOS CXProvider may reject incoming call registration before the user sees it (DND, blocklist, unentitled). Since signaling is disconnected at push-receive time we cannot decline immediately. The server replays the incoming event on next handshake reconnect, which re-enters Path 2 where the SIP line is now known. Replaced the silent drop with a descriptive log and explanation of the recovery path. Path 2 (__onCallSignalingEventIncoming): when reportNewIncomingCall returns an unexpected error (callRejectedBySystem on Android, unknown/internal on iOS) send a DeclineRequest immediately. The call was never shown to the user so performEndCall will not fire. _signalingModule.execute returns null when disconnected — the ?. operator handles that safely.
…-1186) (#1111) * fix(ios): add 8s timeout to getUserMedia in incoming call handler (WT-1186) getUserMedia is called inside the PushKit 30s OS deadline window during incoming call answer. If the camera driver hangs or AVFoundation blocks, the entire budget is consumed and iOS silently kills the app — the call is dropped with no error logged. Add an 8s timeout to the userMediaBuilder.build() call in __onCallPerformEventAnswered(). If exceeded, a warning is logged and TimeoutException is thrown so the existing error handler can decline the call gracefully instead of letting the OS kill the process. allowAudioFallback: true remains in place to handle permission denials before the timeout path is reached. * refactor(call_bloc): extract getUserMedia timeout handler to private method Extract the onTimeout callback into a private _onGetUserMediaPushKitTimeout() method and the 8s duration into a top-level const, per single-expression callback convention. Addresses Copilot review comment on PR #1111.
…46) (#1110) * fix(signaling): guard Transaction against double-complete race (WT-1046) Add _isDone flag and _finish() helper to Transaction so that all three terminal paths (handleResponse, terminateByDisconnect, _onTimeout) are symmetric. Previously only _onTimeout had the isCompleted guard; a late server response arriving after a timeout could call _completer.complete() on an already-completed Completer, throwing StateError and cascading into WebtritSignalingTransactionTimeoutException for all ICE transactions. Add unit tests covering all terminal paths and race scenarios using fake_async for deterministic timer control. * fix(signaling): log warning on duplicate Transaction completion attempt * fix(signaling): address Copilot review — update docs and wrap catchError with unawaited
CallerIdSettingsRepository.create and another provider create: in main_shell.dart call getLocalSystemInfo() synchronously. If system info is absent (cleared during session cleanup after FGS failure, or never fetched in this session) the provider throws StateError and crashes the app. Add a getSystemInfo(cacheFirst) call to onMainShellRouteGuardNavigation, executed after the auth check and before navigation resolves. If system info cannot be loaded — no cache and network unreachable — redirect to the login screen. This prevents the crash observed on Xiaomi after FGS recovery: CallerIdSettingsRepository.create → getLocalSystemInfo() → StateError: "No system info in cache" (main_shell.dart:257)
* fix(WT-1083): use processingStatus to guard outgoing call reconciliation Instead of skipping all unacknowledged outgoing calls during StateHandshake reconciliation, skip only those where OutgoingCallRequest was not yet sent. Adds isPreOfferSent getter to CallProcessingStatus enum with an explicit allowlist of pre-send statuses. Calls that have passed outgoingOfferSent but are absent from the handshake are treated as dead and force-terminated. * refactor(WT-1083): move handshake reconciliation logic to CallState Extract callsToTerminate(List<String> activeLineCallIds) into CallState so the logic is testable without BLoC dependencies. CallBloc converts signaling Line objects to callId strings before delegating to the method. Add unit tests covering: skip in-flight outgoing, terminate post-offer outgoing, keep calls present in handshake lines. * perf(WT-1083): use Set<String> for handshake line lookup in callsToTerminate Reduces membership checks from O(n) to O(1) per active call. * refactor(WT-1083): return List<ActiveCall> from callsToTerminate, drop loop label - callsToTerminate now returns List<ActiveCall> instead of List<String>, removing the redundant retrieveActiveCall lookup in _handleHandshakeReceived - Remove activeCallsLoop label — no nested loops, plain continue is sufficient - Update test assertions to compare ActiveCall objects directly * fix(WT-1083): cancel pending OutgoingCallRequest when call is hung up (#1113) * fix(WT-1083): cancel queued OutgoingCallRequest on hangup Add `cancelByCallId` to `SignalingRequestQueue` and expose it as `cancelRequestsByCallId` on the `SignalingModule` interface. Call it at the start of `__onCallControlEventEnded` so that when the user presses hangup while offline (OutgoingCallRequest still waiting in the queue), the request is dropped immediately rather than being sent on the next reconnect. Without this fix: - The 30-second queue timeout blocked `__onCallPerformEventEnded` via the sequential transformer, delaying ringback stop and PeerConnection disposal by up to 30 seconds. - On internet restore before the timeout, flush() sent OutgoingCallRequest before HangupRequest, causing the callee to briefly see a phantom incoming call. `SignalingHubModule.cancelRequestsByCallId` is a no-op — the hub module routes requests directly without a local queue. * fix(WT-1083): implement cancelRequestsByCallId in all SignalingModule impls Add no-op `cancelRequestsByCallId` to all `SignalingModule` implementations and test fakes that were missing the new interface method: - WebtritSignalingService (delegates to _requestQueue.cancelByCallId) - SignalingHubModule (no-op — hub routes directly, no local queue) - Integration test fakes in signaling_service_android and _ios - _FakeSignalingModule in signaling_reconnect_controller_test * fix(signaling-queue): guard flush() against concurrent cancelByCallId race Replace removeFirst() with remove(entry) in both branches of flush() so that a concurrent cancelByCallId() call that removes the entry during an await suspension does not accidentally pop the next unrelated entry. Add unit tests covering: immediate NotConnectedException on cancel, cancelled requests skipped by flush, cross-call isolation, and the identity-based removal guard under concurrent cancel. * fix(WT-1083): skip disconnecting calls on reconnect and retry lost HangupRequest (#1114) * fix(WT-1083): skip disconnecting calls on reconnect and retry lost HangupRequest Bug A: _safeRenegotiate now skips calls in disconnecting status when signaling reconnects. Previously UpdateRequest was sent for dying calls, keeping the server-side leg alive and causing the callee phone to keep ringing after the local user pressed hangup. Bug B: _handleHandshakeReceived now retries HangupRequest for any call that is locally disconnecting but still present in the server handshake lines. This covers the case where the hangup was lost while signaling was offline and the request never reached the server. * fix(WT-1083): add .ignore() to fire-and-forget HangupRequest retry Make the unawaited intent explicit and consistent with the existing pattern in the file (e.g. _signalingModule.execute(...)?.ignore()). * fix(WT-1083): hang up orphaned outgoing call when connection and BLoC state are both absent (#1115) * fix(WT-1083): hang up orphaned outgoing call when connection and BLoC state are both absent When the user hangs up an outgoing call while offline, performEndCall removes the call from both BLoC activeCalls and CallKeep. If the HangupRequest was lost offline, the server-side leg stays alive indefinitely — HandshakeProcessor had no branch for the case where connection == null and the call is not in activeCallIds. Add an else-if branch after the stateDisconnected check: if the CallKeep connection is null, the call is absent from BLoC activeCallIds, the latest server event is not incoming or terminal, and no AcceptedEvent exists in the call log — emit HangupSignalingAction to tear down the orphaned server leg. Guards: - AcceptedEvent check: preserves app-restart restoration for accepted calls (where connection is also null but a RestoreCallAction should be generated) - activeCallIds check: on iOS getConnection() always returns null; this guard prevents hanging up calls that are still active in BLoC Add 5 unit tests covering the new branch and each guard condition. * refactor(WT-1083): hoist callEventLogEntries above connection block to avoid re-scan Move callEventLogEntries, earliestCallEvent, and acceptedLogEntry computation before the getConnection() block so the orphaned-call guard can reuse acceptedLogEntry == null instead of re-traversing callLogs. Replace the now-redundant latestCallEvent alias with the already-computed callEvent in the isTerminated check. * fix(WT-1083): keep outgoing call alive for 30s while signaling reconnects (#1116) * fix(WT-1083): wait full 30s for signaling reconnect before ending outgoing call Removes the early-exit on signalingFailed in __onCallPerformEventStarted. Previously the firstWhere returned on the first SignalingConnectionFailed (~300ms), triggering callkeep.endCall() before the reconnect controller had a chance to recover. Now the wait only exits on signalingReady or after the new kOutgoingCallSignalingWaitTimeout (30s), giving the reconnect controller the full window to restore connectivity while the call stays in DIALING. * fix(WT-1083): skip EndLocalCallAction for BLoC-managed calls in HandshakeProcessor Loop C was generating EndLocalCallAction for any Callkeep connection absent from the server's handshake lines, including outgoing calls that the BLoC is actively managing but has not yet sent an OutgoingCallRequest for. This caused the call to be torn down via callkeep.endCall() the moment the first handshake arrived after reconnect, while __onCallPerformEventStarted was concurrently preparing the SDP offer. Guard: skip connections whose callId is in activeCallIds — those calls are managed by the BLoC and are not stale orphans. * fix(WT-1083): suppress reconnect snackbar when active call is on screen The call screen already shows "No internet connection / Connecting to the remote server" during a signaling reconnect, so the SignalingConnectFailed snackbar is redundant and clutters the UI. Skip the notification in onConnectionFailed when state.isActive is true. * refactor(WT-1083): address Copilot review comments - constants.dart: use platform-agnostic wording in kOutgoingCallSignalingWaitTimeout doc - call_bloc.dart: update stale comment referencing old timeout constant - call_bloc.dart: extract onConnectionFailed closure to _handleConnectionFailed method - call_bloc.dart: remove inline comments added in previous commit - handshake_processor.dart: remove inline comment added in previous commit * fix(WT-1083): exit signaling wait immediately when user hangs up (#1117) * fix(WT-1083): exit signaling wait immediately when user hangs up When the user pressed hangup while __onCallPerformEventStarted was blocked in firstWhere waiting for signaling (outgoingConnectingToSignaling), the await would continue for the full 30s timeout before the call screen closed. Two changes: 1. firstWhere now also exits when the call leaves outgoingConnectingToSignaling (processingStatus changed or call removed) so a user hangup unblocks the await immediately. 2. The "not connected" block checks whether the call is still in outgoingConnectingToSignaling before performing endCall/notification. If the hangup flow already took over, only event.fail() is called to avoid a double-end and a spurious CallWhileOfflineNotification. * fix(signaling): prevent post-cancel enqueue blocking in SignalingRequestQueue cancelByCallId now marks the callId as terminating so any subsequent enqueue for the same callId is rejected immediately instead of waiting up to 30 s for a queue timeout. This fixes the 8.5 s call screen freeze after pressing hangup while offline: __onCallControlEventEnded calls cancelRequestsByCallId before the HangupRequest is created, so the new request was not covered by the earlier cancel and blocked the __onCallPerformEventEnded handler. failAll clears the terminating set so a fresh session can reuse the same callId. Three unit tests added to signaling_request_queue_test.dart. * refactor(WT-1083): address Copilot review comments on PR #1117 - Extract firstWhere predicate to _shouldExitOutgoingSignalingWait private method (comments #1 and #4 — multi-statement callback) - Fix DartDoc of _terminatingCallIds: remove non-existent [performControlEnd] reference, replace with plain-text description (comment #2) - Add removeTerminatingMark(callId) to SignalingRequestQueue and expose as clearTerminatingMark on SignalingModule interface; call it in __onCallPerformEventEnded via try/finally to bound the set size instead of keeping entries until failAll (comment #3); implement in all four SignalingModule implementors - Add removeTerminatingMark unit test to signaling_request_queue_test.dart * test: add clearTerminatingMark no-op to SignalingModule test fakes * fix(call): address Copilot review comments on PR #1094 - handshake_processor: use earliestCallEvent (not callEvent/latest) in the orphan outgoing-call guard so incoming calls that have received ProceedingEvent/RingingEvent are not mistakenly hung up - signaling_hub_module: remove leftover DEBUG _logger.info from execute() - call_bloc: fix nullable Future — chain ?.ignore() instead of .ignore() to avoid a potential NoSuchMethodError on a null result
After `?.catchError(...)`, Dart short-circuits the chain if execute() returns null, so the receiver of `.ignore()` is never null at that point. Replace `?.ignore()` with `.ignore()` to resolve the invalid_null_aware_operator warning.
…al (#1122) * fix(push_tokens): prevent crash when token retrieval completes after bloc teardown Token retrieval for FCM and APNS is started as an unawaited async operation and can outlive the bloc lifecycle. On logout the bloc is closed while a token fetch may still be in flight. When the fetch completes it tries to dispatch an event to an already-closed bloc, causing a StateError crash. Added a lifecycle check before dispatching to prevent this. * fix(push_tokens): address Copilot review — missing guard, misleading logs, regression test - Guard add() in APNS shouldRetry callback against closed bloc - Replace inaccurate 'failed after max attempts' log with 'completed without a token' - Expose FCM and APNS retrieval methods as @VisibleForTesting - Add regression tests covering token arrival after bloc close for both FCM and APNS paths * fix(push_tokens): remove unused import in test file * fix(push_tokens): remove unnecessary imports flagged by analyzer
…ushBound mode (#1130) * fix(signaling-service): stop orphaned foreground service in pushBound mode In pushBound mode the foreground service was expected to live only while the Activity is connected. When a call is declined before the Activity launches (e.g. from lock-screen, or due to the auto-decline bug), the Activity never subscribes to SignalingHub, hasSubscribers stays false, and the service runs indefinitely - behaving like an unintended persistent mode. Changes: - Add mode field to PSignalingServiceStatus (Pigeon Dart + Kotlin) so the background Dart isolate knows the service mode without reading SharedPrefs - Pass mode = isPushBound ? PUSH_BOUND : PERSISTENT in synchronizeIsolate() - Add SignalingHub.onHasSubscribersChanged callback, fired on 0 <-> 1 transitions so the manager reacts without polling - Add SignalingForegroundIsolateManager.isPushBound field; when true, wire the hub callback to schedule a 30s cleanup timer on last-unsubscribe and cancel it on next-subscribe - When the timer fires (no subscriber for 30s in pushBound mode), call PSignalingServiceHostApi().stopService() to stop the orphaned service (START_NOT_STICKY prevents OS restart) - Thread isPushBound through signaling_sync_handler.dart constructor and configChanged check * refactor(signaling-service): make pushBound grace period configurable Change timeout from 30s to 10s -- 30s had no concrete justification, Activity subscribes within 1-3s on any device so 10s is sufficient headroom. Expose as constructor parameter pushBoundNoSubscriberGrace (default 10s) so tests can pass a short duration without sleeping and callers can tune it if needed. * test(pushBound): cover cleanup-timer lifecycle in isolate manager - Extended _FakeSignalingHub with onHasSubscribersChanged + simulateSubscriberChange so tests can drive subscriber transitions without IsolateNameServer. - Added @VisibleForTesting stopServiceOverride to SignalingForegroundIsolateManager so unit tests can intercept _requestServiceStop() without binary messenger. - Fixed _start(): schedule cleanup timer immediately when isPushBound and hub has no subscribers at start time (initial push-started state, Activity not yet connected -- the timer was previously never scheduled in this case). - Added 5 tests covering: no-subscriber grace-period stop, subscriber arriving within grace cancels timer, last-subscriber-leave re-schedules timer, explicit stop() cancels timer, persistent mode never schedules timer. * fix(lint): promote _testStopService to local before null check * fix(signaling-service): address Copilot review comments on PR #1129 - SignalingHub._handleUnsubscribe: guard with wasNotEmpty so onHasSubscribersChanged(false) fires only on real 1→0 transitions, not spurious removes of unknown consumers when already empty - _requestServiceStop: wrap PSignalingServiceHostApi().stopService() with unawaited+catchError to handle platform channel failures explicitly - Fix doc comment: _pushBoundNoSubscriberGrace → pushBoundNoSubscriberGrace - pigeons/signaling.messages.dart: add required this.mode to constructor to keep the Pigeon input file consistent with the generated output * refactor(pushBound): remove redundant initial hasSubscribers check at start The push-notification (FSM) isolate always subscribes to the hub before the Activity connects, so the cleanup timer should start from the 1→0 transition (push isolate leaves), not from service start. Removing the initial check in _start() means: - no spurious timer that gets immediately cancelled on every push - the 10s grace window begins at the right moment (push isolate left, Activity has not yet connected) Updated tests to reflect the real flow: push isolate subscribes first, then unsubscribes, then Activity connects or doesn't. * docs(pushBound): document FGS lifetime after push callback completes
…24) (#1371) The recents list and the contact opened from it ("View Contact") joined a call-log number to a contact with no source tie-break, so when a number was shared by a local and an external (PBX) contact the winner was left to SQLite's unspecified row order (the first row kept by _rowsToRecent). That let the list and the opened contact card show a different source than the call screen, which reads as mixed name/avatar. watchLastRecents and getRecentByCallId now order the joined rows by contactsTable.sourcePriorityOrder() so the external contact is kept deterministically. watchLastRecents keeps createdAt/hungUpAt as the primary ordering (orderBy on a joined statement replaces the term list, so the full list is passed) and applies the source priority only as a per-call-log tie-break. Covered by recents_dao_test (both insertion orders, list order preserved, non-colliding numbers unaffected).
…8) (#1372) * feat: surface call network quality from Janus slowlink events (WT-1008) Handle the IceSlowLinkEvent that Janus emits (and Core already forwards) before a hard ICE failure, and show a low-footprint signal meter beside the call timer. - CallBloc: new IceSlowLinkEvent branch resolves the active call by line and drives slowlinkDetected/Cleared/Hidden mutations; a DebounceMap auto-hides the indicator once events stop, with a brief "recovered" confirmation first. - ActiveCall gains a transient networkQuality field, mirroring iceConnectionIssue. - New CallNetworkQuality model (severity/uplink/media/recovered) with a pure, unit-tested severity heuristic over slowlink frequency and packet loss. - CallNetworkQualityMeter widget: icon-only signal bars + direction arrow + audio/video glyph, severe label only at severe; coral stays reserved for failures. Rendered beside the timer in CallInfo, suppressed during a real iceConnectionIssue. - l10n: callNetworkQuality_* keys in en/it/uk. * feat: animate call network-quality meter transitions Wrap the meter beside the call timer in an AnimatedSwitcher (fade + horizontal size) keyed by the full meter signature, so appearing/disappearing and every severity/direction/recovered change cross-fade smoothly instead of popping. Wrapped in a RepaintBoundary to isolate the animation repaints. * fix: keep call timer centered, move network-quality meter to its own line The meter sat beside the timer, so its width changes (and the wide severe label) reflowed the FittedBox'd call header and jolted the centered timer sideways. Move the meter to a fixed-size (60x18) reserved slot on its own line directly below the timer: the call header footprint is now constant across all states, so the FittedBox never rescales and the timer never shifts. The meter is icon-only (bars + direction arrow + audio/video glyph); the descriptive label moves to a Semantics label instead of taking horizontal space. * fix: show network-quality label on its own centered line below the timer Restore the visible severe label (it was moved to a Semantics-only label when the meter was made icon-only). The meter now sits on its own centered line directly below the timer rather than beside it, so its width -- including the label -- never pushes the centered timer sideways. It fades and grows in/out with a vertical SizeTransition. * fix: morph network-quality meter changes and source its colors from theme Two polish fixes for the slowlink meter: - State changes (severity / direction / recovered) are morphed in place -- bar colors tween via AnimatedContainer, the row resizes via AnimatedSize -- instead of cross-fading the whole widget, which flickered. The parent AnimatedSwitcher is now keyed by presence only, so it fades only on show/hide. - Severity colors come from the theme's semantic SnackBarStyles palette (warning = degrading, success = recovered) with ColorScheme fallbacks, instead of hardcoded hex constants. Coral/error stays reserved for real failures. * style: address Copilot review nits on the network-quality meter - Derive the severe label TextStyle from textTheme.labelMedium instead of a raw TextStyle literal. - Separate the Flutter SDK import from package imports in the model test (6-group rule). - Clarify the severeLabel doc: it is always the Semantics label, shown as visible text only at severe. (The hardcoded-color note was already resolved by sourcing colors from the theme.)
* fix(app_database): clear drift codegen warnings - bump drift/drift_dev floor to ^2.32.1 (resolves the references() "simple class name" false positive seen on drift_dev 2.31.0 + analyzer 10.2.0; fixed upstream in drift_dev 2.32.1). Now resolves drift 2.33.0 and sqlparser 0.44.4. - add the referenced-table imports so drift can resolve the customConstraint foreign keys in contact_emails and favorites (matches contact_phones). - drop the redundant UNIQUE on cdrs.call_id (it is already the primary key) and recreate the table via schema migration v23, with data-integrity tests. Codegen now runs with 0 warnings, analyze is clean, and migration tests pass. * refactor(app_database): drop unnecessary foreign_keys toggle in cdrs v23 migration cdrs has no inbound foreign keys, and onUpgrade already runs migrations with foreign_keys disabled, so the per-migration PRAGMA toggle is redundant and the trailing re-enable coupled correctness to the migration reaching that line.
* feat(login): enforce deterministic login options order The login tabs (OTP vs Password) were rendered in whatever order the backend /api/v1/system-info adapter.supported[] array returned, which is not stable across requests, so the tab order and the default selected tab could change between logins. Impose a deterministic client-side order via sortLoginTypes(): the regular password login is first by default (faster for frequent users; OTP delivery takes a moment anyway). The order is overridable at build time through the WEBTRIT_APP_LOGIN_TYPES_ORDER dart-define for per-flavor needs. Backend supported[] now only decides which options are shown, not their order. WT-1464 * refactor(login): drive sign-in order from app config instead of env Move the login tab order knob out of the build-time dart-define and into the appearance/app config so it can be managed per application from the configurator, alongside the existing bottom-menu and settings ordering. - webtrit_appearance_theme: add AppConfigLogin.signinOrder (List<String> of login type names), default passwordSignin, otpSignin, signup. - app.config.json: ship the default signinOrder. - LoginConfig + LoginMapper: carry signinOrder through to the login feature. - LoginCubit: take signinOrder and feed it to sortLoginTypes; drop the WEBTRIT_APP_LOGIN_TYPES_ORDER dart-define. The order still resolves through the same pure sortLoginTypes(): an empty or unknown config falls back to the password-first default, and the backend supported[] list keeps deciding only which options are shown. WT-1464 * fix(login): honor signin order in login preview screenshots The configurator preview rendered the login tabs in a fixed order (OTP, Password, Sign up) regardless of AppConfigLogin.signinOrder, so reordering the sign-in tabs in the configurator had no visible effect. Read signinOrder from FeatureAccess and reorder the preview tabs via the same sortLoginTypes() helper the app uses. The interactive login preview now also defaults its selected tab to the first configured one (per-tab snapshots keep their pinned initialLoginType, which is now nullable). WT-1464
Introduce docs/features/ for product-level, living feature overviews, separate from the architecture deep-dives. First entry documents the call feature: where it lives, what the user can do, the single/multi-call UX states, the focused-call seam, key widgets, and the in-progress list-based call-flow redesign with its incremental rollout. Includes a README that sets the convention (one living doc per feature, current behavior first, in-progress work clearly marked).
* feat: expand recents and favorites rows into quick actions instead of instant dial (WT-529) * refactor: unify favorite tile onto the shared call tile (WT-529) * feat: add quick actions expansion to contacts list items (WT-529) * refactor: address review feedback on quick actions expansion
* feat(call): add focused-call state to CallBloc (#1376) Foundation for the list-based call screen redesign. Introduces an explicit "focused" call so the upcoming UI can do "tap a row to focus, act on that row". - CallState.selectedCallId + focusedCall getter (selected when it maps to a live call, otherwise the derived current; null only with no active calls). - CallControlEvent.callSelected + bloc handler, clamped to a live call via copyWithSelectedCall. - copyWithPopActiveCall drops a dangling selection so focusedCall falls back to current. Purely additive and behavior-preserving: nothing dispatches callSelected yet, so selectedCallId stays null and focusedCall mirrors current. Covered by call_state_test (focusedCall fallback/selection, copyWithSelectedCall clamp, pop-clears-selection). * refactor(call): move combined call actions into CallBloc intents (#1378) * refactor(call): move combined call actions into CallBloc intents Second foundation step for the list-based call screen. The call screen used to synthesize multi-step actions itself by dispatching several primitive events in a loop; the upcoming single action area needs one intent per action. - New CallControlEvent intents: answeredEndingOthers ("End & Answer"), answeredHoldingOthers ("Hold & Answer"), swapped. - Pure planners on CallState (planAnswerEndingOthers / planAnswerHoldingOthers / planSwap) return the ordered primitive events; bloc handlers just dispatch them, so the multi-step semantics are unit-testable without a full CallBloc. - CallActiveScaffold now dispatches one intent per action instead of looping; the primitives flow through the same sequential CallControlEvent queue in the same order, so behavior is unchanged. - docs/features/call.md: rollout reflects the refactor/call integration branch (the earlier feature-flag wording was outdated) and stage statuses. Covered by call_state_test planner groups (ordering, only-call edge cases). * refactor(call): keep combined-action plans out of CallState Review feedback: the state layer must not construct or reference events. The plans now live as static helpers on CallControlEvent (events composing events, same layer); CallState only exposes the pure data query otherCallIds. Bloc handlers feed the ids into the plans and dispatch. Tests split accordingly: otherCallIds on the state side, the three plans on the event side. * feat(call): render concurrent calls as a selectable list (#1379) First visible step of the list-based call screen. With more than one call in progress the screen now shows one tappable row per call (status badge RINGING / ON CALL / ON HOLD, name, live duration) under an "N calls - tap to choose" header, replacing the stacked per-call info blocks and per-call button rows. Tapping a row focuses that call; the info block and the action area below act on the focused call only. - New CallList/CallRow widgets (presentational; ticking duration for answered calls, focused-row highlight). - Auto-focus rules in CallState: a new ringing incoming call grabs the focus; when the focused call ends the next ringing incoming call is focused, otherwise focus falls back to the derived current call. - CallActiveScaffold renders CallInfo + actions for the focused call; IncomingCallActions for a ringing focus, ActiveCallActions otherwise. The media overlay keeps following the derived current call, and swap keeps its hold-the-active semantics. - l10n: call_CallList_header (plural) + call_CallList_statusOnCall in en/it/uk; badges reuse the existing ringing/on-hold keys. - docs/features/call.md rollout statuses updated. Tests: CallList widget suite (rows, badges, header visibility, tap dispatch, focused highlight, ticking duration) and CallState auto-focus/pop-refocus cases. * feat(call): focused-call action area with acting-on hint (#1380) Core step of the list-based call screen: the focused ringing call now gets exactly two buttons - Decline and Answer - with an "Acting on: <name>" hint that spells out what answering does to the other calls. The combined-icon buttons (call_end+call "End & Answer", pause+call "Hold & Answer"), which were hard to read mid-ring, are removed. - IncomingCallActions trimmed to Decline/Answer; the combined-icon buttons and their callbacks are gone. - CallControlEvent.answerFocused: pure selection of the single Answer intent - hold the answered others when possible, end the non-holdable ones, plain answer otherwise. Another ringing incoming call keeps ringing. - FocusedActionHint (new widget): names the focused call and the side effect ("will be put on hold" / "will be ended"); shown only with multiple calls. - Answering with other calls present is gated by the interactions debounce like any signaling-dependent action. - l10n: call_FocusedActionHint_{actingOn,willBeHeld,willBeEnded} in en/it/uk. - docs/features/call.md: multi-call section now describes the list-based behavior; widget table and rollout statuses updated. Tests: FocusedActionHint suite (acting-on line, held/ended side effects, precedence, multi-name join, no-effect case), IncomingCallActions (exactly two buttons, no pause glyph, callback wiring), answerFocused selection cases. * refactor(call): remove redesign leftovers and cover the call screen with tests (#1381) Final cleanup of the list-based call screen. - IncomingCallActions: drop the dead enableInteractions parameter (nothing read it after the combined buttons were removed) and the unused keypad text controller; the scaffold call site no longer computes the unused gate. - l10n: delete the obsolete call_CallActionsTooltip_{hangupAndAccept, holdAndAccept} keys from en/it/uk and regenerate gen-l10n + the l10n mapper. - New scaffold-level widget tests (CallActiveScaffold with a mocked CallBloc): single-call states (incoming / active / held) render the right action set with no list and no hint; active+incoming shows the list, the acting-on hint with the hold side effect and exactly the two-button area; Answer dispatches answeredHoldingOthers for the focused call; tapping the active row dispatches callSelected; focusing the active call swaps in the control grid; the 3-call scenario renders three rows and names only the still-active call in the hint. - docs/features/call.md rollout statuses updated. * feat(call): move connection and stream-quality status to the toolbar (#1385) * feat(call): move connection and stream-quality status to the toolbar The call screen toolbar (AppBar title slot) now carries one global status line, freeing the central info block for the caller identity and timer, as in the design. - CallToolbarStatus (new widget), one indicator at a time by priority: signaling/connectivity trouble (red dot + status + "Reconnecting..." with the same debounce the in-info message used), a real media failure (IceConnectionIssue), or media degradation - the signal meter plus an always-visible label. - The quality/failure shown is GLOBAL: new ActiveCall iterable getters worstNetworkQuality (active warning beats recovered, then higher severity) and firstIceConnectionIssue aggregate across all calls. - CallNetworkQualityMeter gains an optional showLabel override so the toolbar can render its own label without duplicating the severe one. - CallInfo slimmed down: callStatus/networkQuality/iceConnectionIssue parameters, the status debouncer and the meter slot are gone; it renders name/number, call description or duration, and processing status only. - l10n: call_ToolbarStatus_reconnecting in en/it/uk. - docs/features/call.md updated (status line description + rollout table). Tests: CallToolbarStatus suite (healthy renders nothing, connectivity text + reconnecting suffix, unregistered without the suffix, quality meter + label at any severity, status-over-quality and failure-over-quality precedence) and aggregation cases for worstNetworkQuality / firstIceConnectionIssue. * feat(call): refine toolbar status states per design Design iteration on the toolbar status line: - Signaling states split per the design: "No internet connection" / "Not registered" are hard states with a coral static dot; the transient reconnect cycle shows an amber PULSING dot with "Connecting..." on the very first connection and "Reconnecting..." once a connection has existed (tracked in the widget). The suffix-style "Reconnecting..." is gone. - The status line is centered in the toolbar. - The quality indicator is bars + direction arrow only: the meter gains a showMediaGlyph override and the toolbar suppresses the mic/camera glyph (the label already says audio/video). - l10n: call_ToolbarStatus_connecting added in en/it/uk. Tests updated: hard states show only their own message; first connection -> Connecting; ready-then-drop -> Reconnecting after the debounce; quality row keeps the arrow and drops the media glyph. * fix(call): align the multi-call screen visuals with the design (#1386) Visual-alignment pass over the list-based call screen: - CallRow gains a colored status dot (amber ringing, green on call, grey held) and the ringing trailing label becomes the short Incoming/Outgoing form (new call_CallList_incoming/outgoing keys in en/it/uk). - The central info block is rendered for a single call only - with multiple calls the rows carry the per-call info, as in the design. - The acting-on hint is now a translucent pill pinned right above the action buttons (hint + buttons share one column so the space-between layout keeps them glued), with the focused name in bold and the affected names highlighted in amber. - Action buttons stay untouched. Tests updated: hint assertions match the rich-text spans, the ringing row asserts the short Incoming label, and the scaffold suite pins the central info block presence for a single call and its absence with multiple calls. * fix(call): make hold and resume act on the focused call (#1387) With two calls the hold slot in the control grid used to be REPLACED by the swap button, so a held call could never simply be resumed and the button ignored which row was focused - pressing it always switched lines via the derived current call. The focus model makes swap redundant: switching lines is focusing the other row. - The hold slot is now always Hold/Resume for the FOCUSED call, with a pause/play glyph reflecting the state. - Resume dispatches the new resumedHoldingOthers intent: the other answered, not-yet-held calls are put on hold first (CallState.otherCallIdsToHold), then the focused call is resumed - exactly one call stays live. With two calls this covers the old swap. - Hold stays a plain setHeld on the focused call. - The swap button, its callback chain, the swapped event/plan, the widget key and the swap tooltip l10n keys are removed. Tests: resumeHoldingOthersPlan ordering (+ resume-only when nothing else is live), otherCallIdsToHold filtering (skips held, ringing and the target), and scaffold cases - Hold on an active focus dispatches setHeld(true); a held focus shows the play glyph, no swap button, and Resume dispatches the new intent. * fix(call): match the call row overlay polarity to the design (#1388) The rows tinted themselves with colorScheme.surface, which resolves to a dark color on the standard theme - so the focused row came out DARKER than the rest, inverting the design. Rows now use light overlay tints of the on-screen text color (white on the call gradient): the focused row is the brighter one with a light border, unfocused rows stay dimmer. Corner radius and padding bumped to the design proportions (16/14 with a 6px gap). docs: rollout table - Hold/Resume row flipped to merged, new row for this polish. * feat(call): mark video lines in the call list (#1389) A small camera glyph next to the trailing duration/direction label marks the rows whose call carries video (ActiveCall.remoteVideo - confirmed remote video track or the negotiated video flag), matching the design. Tests: a video line shows the glyph, voice lines do not. docs rollout updated (row overlay polish flipped to merged, this badge added). * fix(call): route the redesign colors through the theme pipeline (#1390) Removes the fixed colors the redesign introduced and gives them proper theme roles, so branded builds restyle them from the theme JSONs: - webtrit_appearance_theme: CallPageConfig gains callList (CallPageListConfig: row/focused-row/border overlays + ringing/on-call/held dot colors) and actingOnHint (CallPageHintConfig: pill background + affected-name highlight); codegen regenerated. - assets/themes: original.page.{light,dark}.config.json set the design values under "dialing" (white overlay tints, amber/green dots, scrim pill, amber highlight) - alpha-first #AARRGGBB hex. - App: CallScreenStyle gains CallListStyle and FocusedActionHintStyle (with lerp), mapped in CallScreenStyleFactory with scheme-derived defaults when a theme omits the section. - Widgets: CallRow and FocusedActionHint consume the style objects; the only remaining in-widget fallbacks derive from the ambient text color/scheme - no Colors.* or fixed hex anywhere in the call feature. Tests: full call suite green (452); grep for fixed colors over the branch diff is clean. * fix(call): drop the programmatic uppercase on call row badges (#1392) The row status badge uppercased its localized text at render time (.toUpperCase() on call_description_held etc.) - a typographic hack that also broke the patrol transfer assertion, which looks for the plain "On hold" string. The badge now renders the localization as is; the patrol audit change is reverted since the original assertion matches the badge again. The visual caps treatment, if still wanted, belongs to the text style (theme), not to string mangling. Widget tests updated accordingly. * refactor(call): address PR review feedback on the integration branch (#1393) Review findings on the refactor/call -> develop PR: - AGENTS.md requires single-expression callbacks: the multi-statement closures the redesign added to CallActiveScaffold (camera toggle, hold/resume, hangup x2, answer) move into private focused-call intent helpers; the widget tree passes tear-offs. The answer helper re-derives the other-call sets from widget.activeCalls, so behavior is identical. - docs/features/call.md widget table: CallInfo no longer shows the network quality meter (it lives on the toolbar status line) - the row now lists name / number / call description / timer. The remaining review comment (ActiveCall import) is a false positive: call_state.dart is part of call_bloc.dart, so the import provides ActiveCall. No behavior changes; full call suite green (452).
* feat: add Play Core in-app update flow for Android (WT-945) * docs: describe the app update and version compatibility flows * test: cover the failed flexible update branch * refactor: delegate lifecycle handling to a private method * refactor: check for app update on startup only
#1397) * style: remove inaccurate comment from voicemail audio cache file guard * style: add accurate comment for iOS cache file guard * style: add comment for local path condition in cache file guard
…ack (#1398) * fix: localize missed call notification title and unknown caller fallback Resolve hardcoded 'Missed Call' and 'Unknown' strings that appeared in local push notifications regardless of the device language. The isolate context now reads the persisted locale via LocaleRepositoryPrefsImpl and passes resolved l10n strings through PushNotificationIsolateManager and CallBloc constructors. New ARB keys notifications_missedCall_title/unknownCaller added for EN/IT/UK. * refactor: inject onMissedCall callback into CallBloc instead of LocalPushRepository + title fields CallBloc no longer holds LocalPushRepository or missedCallTitle directly. The caller (main_shell) owns the notification logic via an injected callback, keeping the bloc unaware of push internals. * refactor: inline onMissedCall at call site, remove _showMissedCallNotification wrapper * refactor: make _onMissedCall field private in CallBloc * fix: resolve l10n title lazily inside onMissedCall lambda, not during BlocProvider.create * refactor: inject onMissedCall callback into PushNotificationIsolateManager Replace localPushRepository, missedCallTitle, and unknownCallerFallback constructor params with a single Future<void> Function(String, String?) callback so the manager does not depend on push_notifications internals. * fix: guard lookupAppLocalizations against unsupported locale in push isolate PushIsolateContext.locale returns the 'und' sentinel when no locale has been selected, causing lookupAppLocalizations to throw FlutterError. Resolve the stored locale to a supported one (falling back to EN) before the lookup. Also document the BlocProvider.create l10n restriction in AGENTS.md.
…(WT-1236) (#1400) * feat: surface camera-permission downgrade when answering a video call Answering an incoming video call without camera permission falls back to audio-only. Communicate that downgrade with two layers: a soft snackbar with an Open Settings action at answer time, and a persistent camera-button hint that re-checks the live permission and routes to app settings while it stays denied. The downgrade is derived in the bloc (the offer requested video but the resulting stream is audio-only) and confirmed against the live camera permission; the media builder is left unchanged. * fix: gate camera permission-denied tap and clear stale downgrade hint Address review findings on the camera-permission downgrade UX: - The permission-denied camera-button tap bypassed the enableInteractions gate that the normal toggle respects, so a mid-call grant could dispatch a camera enable (and SDP renegotiation) during a renegotiation/not-ready window. Route it through the same gated local as the normal toggle. - ActiveCall.videoPermissionDenied was cleared only on the camera-enable success path. Clear it on the existing-video-track path too, and re-derive it from the live permission when an enable attempt fails, so the button no longer reports "permission denied" after the user has granted access. * fix: harden camera permission-denied flow per review Address Copilot review feedback on the camera-permission downgrade UX: - Move the optional isCameraPermissionGranted param after the required ones (required-before-optional convention). - Make the live permission check failure-tolerant: a throwing permission plugin no longer breaks call answering or camera toggling; the unknown case is treated as granted (no block, no misleading hint). - Skip the post-await videoPermissionDenied emit when the call has ended. - Disable the camera button (onPressed: null) when its handler is gated, consistent with the mute button, instead of leaving it a no-op. - Add widget tests covering the permission-denied tooltip and the tap paths (enable when granted, open settings when still denied).
…nse file (#1401) Add a Third-party licenses entry to the About screen via Flutter's showLicensePage (auto-collects every bundled pub package, incl. BSD-2 icon_decoration) with a new l10n key in en/it/uk. Add tool/generate_licenses.dart (melos run licenses:generate) that aggregates the license text of all third-party packages from the resolved dependency set into THIRD_PARTY_LICENSES.md, plus a CI guard that fails a PR when the file drifts out of sync. WT-771
…1363) (#1399) * fix: enforce exclusive voicemail playback via shared AudioPlayer (WT-1363) Introduce VoicemailPlaybackController (ChangeNotifier) that owns a single AudioPlayer for the voicemail screen. AudioView becomes a StatelessWidget that reads the controller from context: the active tile renders AudioPlayerInterface against the shared player; inactive tiles render a static play button. Switching tracks stops the previous one automatically since only one AudioPlayer exists. The controller also handles audio session setup, LockCachingAudioSource construction, playback-completed reset, and app-lifecycle pause -- concerns previously scattered across every AudioView instance. * fix: debounce loading state to prevent blink on cached voicemail switch Show AudioPlayerInterface immediately on track switch (optimistic UI). The loading spinner is deferred by 200 ms via a Timer -- it only fires for slow/uncached sources and is cancelled when setAudioSource resolves before the threshold. Eliminates the play-button blink when toggling between already-cached messages. * refactor: replace raw Timer with project Debounce utility in playback controller * fix: address Copilot review findings in voicemail playback controller - Retry after error: fall through play() early-return when _error != null, so tapping play on an errored tile re-runs setAudioSource instead of calling player.play() on an unloaded source. - Race condition: add a _generation token; each async await checks whether the token is still current, and stale continuations return early without mutating state or calling the player. - Error recovery: stop the player in the catch block so a previously playing voicemail does not continue after a new-track load failure. - Path traversal: reject a cacheKey of '.' or '..' in resolveCacheFile, which separator-stripping alone let escape cacheBasePath via path.join. - Inactive slider: wrap AudioSlider in IgnorePointer in _InactiveAudioView to prevent users from dragging a no-op seek control. - Testability: accept optional AudioPlayer and setupAudioSession in the constructor for unit-test injection, and expose resolveCacheFile via @VisibleForTesting (production path unchanged). - Tests: unit tests covering state transitions, debounce timing, race guard, error handling, lifecycle, completion, dispose, and cache-key path traversal.
* fix(recents): update RecentTile call logic and icons - Dynamically change dial icon based on last call type (audio/video). - Ensure quick access dial button initiates the correct call type. - Show only the alternative call type in the expanded actions bar to avoid redundancy. * test(call_tile): update test to reflect new audio call action * fix(recents): gate audio quick action by videoEnabled and drop leftover comment * test(call_tile): cover custom dialIcon on the dial button * test(call_tile): assert audio action placement and callback instead of label count
…ed reason(WT-1419) (#1402) * feat: show neutral notification when an outgoing call fails for an unrecognized reason The hangup handler already maps known SIP codes (rejected, busy, not-found, invalid number, unwanted) to neutral message notifications. The default branch only logged and reported to Crashlytics, so any other failure was silent. Add a generic CallUnableToCompleteNotification (extends MessageNotification, so it renders neutral, not red) backed by a new signalingResponseCode_unableToComplete l10n key (en/it/uk) and submit it from the default branch, keeping the existing Crashlytics record. Intentionally-silent codes stay silent: byCode returns null for unknown/normal-termination codes, which hit the silent null case. * fix: drop stray brace in hangup Crashlytics error title * feat: align known call-failure notifications with the ticket wording Shorten the four known outgoing-call failure messages to the concise neutral labels from the spec: "Call rejected", "User busy", "User does not exist", "Invalid number format" (en/it/uk). The previous rejected string in particular was a long, technical phrase that did not match the actual reason.
…78) (#1403) Replace the placeholder TODO in CallBloc.onChange with a short pointer comment and move the full explanation into docs/signaling_architecture_target.md under a new "Background call-active edge (onChange)" subsection. The block re-notifies SignalingReconnectController when CallState.isActive flips while the app is backgrounded - a gap _onAppLifecycleStateChanged cannot cover because it samples isActive only at the foreground/background transition. The doc section explains each notify* call; the code comment links to it.
…7) (#1404) Replace the placeholder TODO in CallBloc.onError with a short pointer comment and move the rationale into docs/signaling_architecture_target.md under a new "Call finalization on signaling loss (onError)" subsection. onError is the BLoC catch-all and stays a pure logger by design: it has no call context and a live call must survive a transient signaling drop. Finalizing a call whose signaling is lost is handled by four narrower paths with call context - survive-and-recover, remote hangup, requestCallIdError cleanup, and the visible ICE-issue flag. The doc section tables these; the code comment links to it. No behaviour change - documentation only.
…ng guide in AGENTS.md (WT-1077) (#1405) * docs: relocate call architecture/scenario docs under features (WT-1077) The call-feature deep-dives lived at the docs root while the feature doc lives in docs/features/. Move them next to the feature so root keeps only cross-cutting, component-level docs and docs/features/ holds everything about a given feature: - docs/call_architecture.md -> docs/features/call_architecture.md - docs/incoming_call_scenarios.md -> docs/features/incoming_call_scenarios.md Also fix the placement from #1403/#1404: the CallBloc onChange/onError subsections were added to signaling_architecture_target.md (the signaling-component doc) but are CallBloc behaviour. Move them into features/call_architecture.md under a new "Signaling edges (onChange / onError)" section; signaling_architecture_target.md now points there. Update all cross-links (features/call.md, features/README.md taxonomy, CLAUDE.md) and the two code comments in call_bloc.dart. signaling_architecture_target.md stays at root as a cross-cutting component doc. No behaviour change - documentation only. * docs: split call product/UX out of call.md into call_ux.md (WT-1077) call.md mixed the product/UX view (what the user does, screen states, key widgets, the in-progress redesign) with feature orientation. Extract the product/UX content into docs/features/call_ux.md and reduce call.md to a short feature index that points to the UX, architecture, scenario and signaling docs. No behaviour change - documentation only. * docs: merge call_architecture.md into call.md (WT-1077) Keep one doc per concern for the call feature: call.md now holds both the feature overview and the CallBloc architecture deep-dive (responsibilities, events, state machine, flows, isolates, key patterns, signaling edges); call_ux.md keeps the product/UX view. Remove the separate call_architecture.md and repoint all references (CLAUDE.md, signaling_architecture_target.md, features/README.md, call_ux.md, and the two call_bloc.dart comments) to call.md. No behaviour change - documentation only. * docs: adopt _ux / _arch feature-doc split for call (WT-1077) Establish a per-feature doc pattern and apply it to call: - <name>.md - index/overview (summary, where it lives, links) - <name>_ux.md - product / UX - <name>_arch.md - code / architecture Move the CallBloc architecture deep-dive out of call.md into call_arch.md, keep call.md as the index, and document the pattern in features/README.md (File pattern + Conventions). Repoint references (CLAUDE.md, signaling_architecture_target.md, call_ux.md, and the two call_bloc.dart comments) to call_arch.md. No behaviour change - documentation only. * docs: move incoming_call_scenarios.md to docs root as cross-cutting (WT-1077) The incoming-call scenarios span push, callkeep and signaling, not just CallBloc, so they belong at the docs root next to the other cross-cutting docs rather than under docs/features/. Move the file back to docs/ and repoint links (features/call.md, features/call_arch.md, features/README.md scenario notes). No behaviour change - documentation only. * docs: drop per-feature index file, rename features README to features.md (WT-1077) The per-feature index (call.md) duplicated the features index. Remove it and let the central index serve that role; move call.md's "Where it lives" into call_arch.md. Rename docs/features/README.md -> docs/features/features.md as the explicit index, and update the file pattern: a small feature stays a single <name>.md, a grown one splits into <name>_ux.md + <name>_arch.md with no redundant index. Point the index row for call at call_ux.md + call_arch.md. No behaviour change - documentation only. * docs: move feature-doc authoring guide from features.md to AGENTS.md (WT-1077) The how-to-write-docs guidance (taxonomy, file pattern, conventions) is agent instruction, not feature content. Move it into AGENTS.md under a new "Documentation" section and reduce features.md to just the index, pointing at AGENTS.md for the rules. No behaviour change - documentation only. * docs: make features.md index a labeled per-feature nav table (WT-1077) Give each feature labeled doc links (UX / Architecture, or Overview for a single-file feature) instead of bare filenames, so the index is a convenient navigation table for all features in the folder. No behaviour change - documentation only. * docs: reformat feature docs (table alignment) (WT-1077) Whitespace-only: align markdown table columns in the feature docs. No content or link changes.
#1407) * fix(call): end native call on signaling hangup for a call not in state * fix(call): report missedWhileConnecting end reason for a hangup not in state Lets callkeep distinguish this never-presented end (which should suppress a stale ghost re-presentation) from a transfer-back, without a timing window.
…1409) * feat: add min_supported_app_version to system-info models (WT-1628) Prepare the data layer for the upcoming force-update gate without any behavior or UI changes. The backend exposes an optional min_supported_app_version (semver string) in GET /api/v1/system-info (null = not enforced); this threads the field through the model layers so a later change can consume it. - api.SystemInfo: new optional top-level `min_supported_app_version`, carried as a raw String? (the API layer stays a dumb transport). - WebtritSystemInfo: new parsed `Version? minSupportedAppVersion` + props. - API->domain mapper: safe parse (String? -> Version?, malformed -> null). - JSON cache mapper: persist the field so cached system-info round-trips. No reads of the field yet; no gates, helper, dialog or l10n. * refactor: tolerant version parsing for system-info min version + tests (WT-1628) Address review on the data-layer prep: the JSON cache mapper used a raw Version.parse for min_supported_app_version, which throws if the persisted value is malformed, empty or not a String. Mirror the tolerant parsing used on the API side and share it. - New util tryParseVersion(Object?) -> Version? (null on null/non-String/ empty/malformed); reused by both the API and JSON cache mappers. - Drop the private _tryParseVersion duplicate from the API mapper. - Tests: tryParseVersion unit; API->domain + JSON cache round-trip (valid / null / malformed-stays-null); api SystemInfo wire contract for min_supported_app_version (present + absent).
…1410) * refactor: unify contact number actions into a single source of truth The inline actions bar and the popup menu each maintained their own hardcoded list of contact actions, so the same action (audio/video call, history, view contact) appeared in both surfaces at once, with diverging labels (History vs View call history, Contact vs View Contact). The More button reopened the full menu, repeating everything already in the bar. Introduce NumberAction plus a single buildNumberActions builder that both surfaces derive from: the inline bar renders the primary actions, the More menu shows only the rest, and long press still exposes the full set on a collapsed row. ContactPhoneTile now builds its overflow menu from the same builder instead of its own hardcoded entries. * fix: do not duplicate the dial call action in the expanded bar The trailing dial button is a static call shortcut that stays visible in both states. When the tile is expanded, drop the inline call action of the same kind as the dial button (audio or video) so the actions bar no longer repeats it.
…rver capability (WT-727) (#1412) * feat(cdrs): gate call history by local flag AND server capability (WT-727) Reintroduce a local opt-in for remote call history on the recents tab. Partially reverts the server-only gating: CDRs are shown only when both signals agree - the local RecentsTabScheme.supportsCallHistory flag AND the server callHistory adapter capability. The DTO reads the supportsCallHistory key and falls back to the legacy useCdrs key on parse, so existing client configs keep their value. The runtime RecentsBottomMenuTab.supportsCallHistory now holds the resolved (local && capability) value, so downstream consumers are unchanged. Also align stale enabled expectations in the appearance_theme parsing test with the committed app.config.json fixture. * refactor(cdrs): address review on the call-history local-flag gating - DTO migration shim uses containsKey instead of `??`, so an explicitly present supportsCallHistory key (even null) wins over the legacy useCdrs key; add a parsing test for the explicit-null case. - rename the mapper-test helper recentsUseCdrs -> resolvedSupportsCallHistory to drop the deprecated useCdrs terminology and match what it returns. * feat(cdrs): add Firebase Remote Config override for call history Introduce a third source for the recents call-history gate. The local config flag (supportsCallHistory) can now be overridden by the Firebase Remote Config boolean `feature_call_history_enabled` via FeatureOverrides, following the same precedence pattern as the other remote overrides. The remote value, when set, replaces the local config flag, but it never bypasses the server callHistory capability. Resolution: (feature_call_history_enabled ?? supportsCallHistory) && callHistory. - FeatureOverrides: add isCallHistoryEnabled + the feature_call_history_enabled key. - BottomMenuMapper.map/_createBottomMenuTab: take FeatureOverrides and apply the override to the recents local flag before the capability gate. - tests: extend the recents matrix with the Firebase override dimension. - docs: document the remote override and resolution order. * refactor(cdrs): address review on the Firebase call-history override - FeatureOverrides: include isLogAnonymizationEnabled in props so Equatable equality reflects that flag. - BottomMenuMapper recents handler: inline to a single-expression callback per project convention (multi-statement logic not allowed in callbacks). - tests: clarify two recents test names (callHistory is advertised).
…1413) * fix(login): allow letters in OTP sign-in identifier field (WT-1642) Account references may be alphanumeric (e.g. ph123x456) even when the backend advertises phone as the only OTP identifier. Aligning the keyboard with the identifier in WT-1433 forced a phone dial pad for the phone-only case, which blocks letters and regressed 1.15.2 behaviour. Use a text keyboard whenever email is not advertised, keeping the email keyboard only when email is an accepted identifier. Covered by extension keyboard-type tests. * style(login): separate external import group in OTP identifier test
…1408) * feat: enforce min_supported_app_version force-update gate (WT-1628) The backend now declares an optional min_supported_app_version in GET /api/v1/system-info (null = not enforced). The app reads it, semver-compares against its own version, and when the app is older it shows a non-dismissible update prompt and does not establish a signaling-connected session. This is the inverse of the existing core compatibility check. - Thread the field through the 4 data layers (api SystemInfo as String?, WebtritSystemInfo as Version?, api + json cache mappers). - Add WebtritSystemInfo.isAppVersionSupported helper (null / 0.0.0 debug guard / below / equal / above). - Gate the 3 system-info sites: login (pre-session block via return null), main bloc (running/persisted session -> AppVersionUnsupported + dialog), autoprovision (parity). - New AppUpdateRequiredDialog + l10n in en / it / uk. - Unit tests for the helper and the login + main-bloc gates. * feat: full-screen update-required UI for the force-update gate (WT-1628) Replace the placeholder AlertDialog with a full-screen, non-dismissible update-required screen matching the approved design. - Dialog.fullscreen + PopScope(canPop: false): no back button, no barrier dismiss; the user must update or log out. - Layout (mirrors the app's blocking-screen pattern): concentric blue badge with an upward arrow + accent sparkle, bold title, muted description, a bordered card contrasting current vs minimum required version (tabular figures), then a primary Update button and a Logout text button. - Uses theme tokens only (colorScheme + ElevatedButtonStyles.primary), so it follows light/dark and white-label themes. - l10n: split the old combined content string into a placeholder-free description plus current/minimum version labels (en / it / uk). - Widget tests: renders title/description/versions/actions, hides Update when no store URL, fires the update/logout callbacks. * fix(main): close force-update gate on root navigator so logout works (WT-1628) The gate dialog is pushed on the root navigator (showDialog defaults to useRootNavigator: true), but the logout/update callbacks resolved Navigator.of(context) against the nested auto_route navigator and used maybePop(), which PopScope(canPop: false) blocks. The dialog therefore never closed on logout; MainScreen was torn down underneath it and the next tap dereferenced a defunct context, throwing "Null check operator used on a null value" in Navigator.of. Capture AppBloc/MainBloc and the root navigator while the context is mounted, and force-pop the gate via the root navigator before dispatching logout. * refactor(main): consolidate min-supported-app-version gate responsibilities (WT-1628) The force-update gate logic was spread across bloc, screen and dialogs, each holding knowledge about the others. Tighten the boundaries: - bloc: extract a pure _resolveCoreVersionVerdict() (app-too-old > core incompatible > compatible) and attach the async store URL separately in _withStoreUpdateUrl(). Drops the minSupportedAppVersion! null-assertion - the AppVersionUnsupported branch now guards non-null itself. - dialogs: AppUpdateRequiredDialog/CompatibilityIssueDialog own their dismissal, popping via their own context (plain pop(), which PopScope does not block). - screen: MainScreen no longer knows about the root navigator or force-pop; it only maps state to a dialog and wires pure update/logout intents. Supersedes the force-pop approach from the previous commit with a cleaner self-dismiss; logout still works and no longer dereferences a defunct context. * refactor(version-gate): extract shared AppCompatibility domain decision (WT-1628) The login gate (LoginCubit) and the in-app force-update gate (MainBloc) each re-derived the same two version checks (core constraint + min_supported_app_version) with their own priority order. Centralize the rule in a pure domain type, AppCompatibility.resolve(systemInfo, appVersion, coreVersionConstraint), and make both call sites thin mappers to their own surface (login notification / bloc state). - Single source of truth for the checks, their priority, the non-null-minimum guard, and the 0.0.0 debug/sideload exemption. - Unifies priority across both gates: app-too-old now takes precedence over a core mismatch in LoginCubit too (previously core-first). Only affects the rare case where both checks fail at once; app-too-old is the more actionable message. - Adds app_compatibility_test.dart covering each outcome and the priority. * refactor(version-gate): inject AppCompatibilityResolver instead of a static call (WT-1628) Make the version-compatibility policy an injected collaborator rather than a direct static call, so it is a single swappable/mockable seam (and a future remote-config/kill-switch policy can replace it without touching consumers). - Add AppCompatibilityResolver interface + const DefaultAppCompatibilityResolver holding the existing rule; drop the static AppCompatibility.resolve factory. - Provide it once via RootApp's MultiProvider (const, no bootstrap registration). - Inject into MainBloc and LoginCubit via constructor; both read it from context. - Update gate tests to pass const DefaultAppCompatibilityResolver(). * refactor(version-gate): move force-update gate to an AppRouter guard + pages (WT-1628) Replace the MainBloc + full-screen dialog overlay with a router-level gate. The dialog approach let the real main content render for a frame before the overlay covered it (visible blink); a guard redirect to a dedicated page never builds the protected content. - AppBloc owns the gate signal: subscribes to systemInfoRepository.infoStream, resolves AppCompatibility via the injected resolver, and holds it in AppState. appCompatibility is added to compareToReevaluate so router guards reevaluate the moment the gate flips (covers mid-session min-version changes). - New version_gate feature: UpdateRequiredScreenPage (app too old) and CompatibilityIssueScreenPage (unsupported core), ported from the former dialogs as @RoutePage screens (PopScope blocks back; store URL resolved on the page). - AppRouter.onMainShellRouteGuardNavigation redirects to the right page based on AppState.appCompatibility (after system-info, before agreements/permissions); recovery guards return to MainShell once authenticated-and-supported again, and bounce logged-out users onward so logout works from the gate. - Remove MainBloc/MainBlocState/MainBlocEvent (it was entirely this gate), both dialog widgets, and their tests. MainScreen is now a plain layout widget. - Add AppBloc gate tests and an UpdateRequiredScreenPage widget test. * fix(autoprovision): use shared AppCompatibilityResolver for the version gate (WT-1628) AutoprovisionCubit re-derived the core/app-version checks in the old order (core first), so when both failed it surfaced "core unsupported" instead of the intended app-too-old priority used by the login and in-app gates. Inject the shared AppCompatibilityResolver and switch on its result, matching the unified priority and removing the duplicated checks. Addresses the Copilot review comment on autoprovision_cubit.dart.
Aligns develop app_version to 1.15.4+2 and callkeep lock to 1.2.0+0 after the 1.15.4 release. develop keeps the callkeep path pin.
… (WT-1631) (#1419) (#1427) * feat(theme): configurable dialog theme and readable dialog background Material 3 derives the dialog background from colorScheme.surfaceContainerHigh, which resolves to the seed color in the original scheme and made the confirm dialog (e.g. logout) render as dark text on a near-unreadable surface. The app also had no ThemeData.dialogTheme at all, so there was nothing to override. - Add a global DialogThemeData (DialogThemeDataFactory) wired into ThemeData.dialogTheme; background falls back to colorScheme.surface and the surface tint to transparent, so all dialogs stay legible without any config. - Add DialogThemeConfig (background/surfaceTint/shadow/barrier colors, elevation, borderRadius, title/content text styles) for global dialog styling. - Extend ConfirmDialogWidgetConfig with background/surfaceTint/elevation/ borderRadius and title/content text styles as confirm-dialog-specific overrides layered on top of the global dialog theme. - Apply the new properties in ConfirmDialog's AlertDialog; extend ConfirmDialogStyle with merge/lerp for the added fields. All new config fields are nullable, so existing themes keep parsing and unset fields fall back to color-scheme roles. * test(screenshots): add dialogs showcase page Add DialogsShowcaseScreenshot: a catalog page rendering confirm / dangerous / alert dialog variants inline so one screenshot shows the dialog theme (background, surface tint, shape, text and button styling). Registered like every other screenshot (router + integration test). The logout ConfirmDialog itself is opened through the real logout button in the existing SettingScreenScreenshot when the configurator preview runs in interactive mode, so no pointer handling is hardcoded here. * fix(theme): keep confirm dialog in its surface and harden preview harness ConfirmDialog.show/showDangerous anchor to the nearest navigator instead of the app-wide root, so the modal stays inside its hosting surface (e.g. the configurator preview) rather than escaping to the host root navigator and losing the phone's Localizations. The originating theme is preserved by showDialog's InheritedTheme capture, so in-app appearance and behavior are unchanged. Screenshot harness: the voicemail preview now provides VoicemailPlaybackController (mirroring VoicemailScreenPage) so AudioView resolves it, and the teardown preview renders a side-effect-free stand-in instead of mounting the real TeardownScreen, which calls stopService() and throws when the native platform is not initialized. * fix(screenshots): render the real TeardownScreen in preview Mount the actual TeardownScreen instead of a hand-copied stand-in so the preview stays in sync when the screen changes. To avoid its initState stopService() call throwing when SignalingServicePlatform is not initialized (configurator preview / web harness), register a no-op mock platform first, but only when none is set so a real implementation is never overwritten. * fix(screenshots): catch only StateError when probing signaling platform SignalingServicePlatform.instance throws StateError when unset, so narrow the guard to that type instead of catching everything, which could mask unrelated failures. (cherry picked from commit ea2d89a)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Release 1.16.0 (minor) of webtrit_phone.
1.3.0(release-only git ref; develop keeps the path pin).Highlights since 1.15.4 include the force-update gate (WT-1628), network diagnostics, list-based call screen, Play Core in-app update, and the call media manager refactor.
Stays DRAFT during ~2-week QA; merges to main after QA passes.